diff --git a/src/components/constraints/ConstraintForm.svelte b/src/components/constraints/ConstraintForm.svelte index 920a1e0265..27da688761 100644 --- a/src/components/constraints/ConstraintForm.svelte +++ b/src/components/constraints/ConstraintForm.svelte @@ -4,7 +4,8 @@ import { goto } from '$app/navigation'; import { base } from '$app/paths'; import { createEventDispatcher } from 'svelte'; - import type { DefinitionType } from '../../enums/association'; + import { DefinitionType } from '../../enums/association'; + import { ConstraintDefinitionType } from '../../enums/constraint'; import { SearchParameters } from '../../enums/searchParameters'; import { constraints } from '../../stores/constraints'; import type { User, UserId } from '../../types/app'; @@ -16,12 +17,14 @@ import AssociationForm from '../ui/Association/AssociationForm.svelte'; export let initialConstraintDefinitionAuthor: UserId | undefined = undefined; - export let initialConstraintDefinitionCode: string | null = ''; + export let initialConstraintDefinitionCode: string | null = null; + export let initialConstraintDefinitionFilename: string | null = null; export let initialConstraintDescription: string = ''; export let initialConstraintId: number | null = null; export let initialConstraintName: string = ''; export let initialConstraintPublic: boolean = true; export let initialConstraintDefinitionTags: Tag[] = []; + export let initialConstraintDefinitionType: ConstraintDefinitionType = ConstraintDefinitionType.EDSL; export let initialConstraintMetadataTags: Tag[] = []; export let initialConstraintOwner: UserId = null; export let initialConstraintRevision: number | null = null; @@ -100,13 +103,24 @@ }>, ) { const { - detail: { definitionCode, definitionTags, description, name, public: isPublic, tags: metadataTags }, + detail: { + definitionCode, + definitionFile, + definitionTags, + definitionType, + description, + name, + public: isPublic, + tags: metadataTags, + }, } = event; const newConstraintId = await effects.createConstraint( name, isPublic, metadataTags.map(({ id }) => ({ tag_id: id })), + definitionType === DefinitionType.CODE ? ConstraintDefinitionType.EDSL : ConstraintDefinitionType.JAR, definitionCode ?? '', + definitionFile ?? null, definitionTags.map(({ id }) => ({ tag_id: id })), user, description, @@ -130,12 +144,14 @@ }>, ) { const { - detail: { definitionCode, definitionTags }, + detail: { definitionCode, definitionFile, definitionTags, definitionType }, } = event; if (initialConstraintId !== null) { const definition = await effects.createConstraintDefinition( initialConstraintId, + definitionType === DefinitionType.CODE ? ConstraintDefinitionType.EDSL : ConstraintDefinitionType.JAR, definitionCode ?? '', + definitionFile ?? null, definitionTags.map(({ id }) => ({ tag_id: id })), user, ); @@ -228,12 +244,20 @@ {\n\n}\n`} + definitionTypeConfigurations={{ + code: { label: 'EDSL' }, + file: { accept: '.jar', label: 'JAR File' }, + }} displayName="Constraint" {hasCreateDefinitionCodePermission} {hasWriteMetadataPermission} {hasWriteDefinitionTagsPermission} initialDefinitionAuthor={initialConstraintDefinitionAuthor} + initialDefinitionType={initialConstraintDefinitionType === ConstraintDefinitionType.EDSL + ? DefinitionType.CODE + : DefinitionType.FILE} initialDefinitionCode={initialConstraintDefinitionCode} + initialDefinitionFileName={initialConstraintDefinitionFilename} initialDescription={initialConstraintDescription} initialId={initialConstraintId} initialName={initialConstraintName} @@ -245,6 +269,7 @@ {initialReferenceModelId} {permissionError} revisions={constraintRevisions} + showDefinitionTypeSelector={true} {tags} tsFiles={constraintsTsFiles} {mode} diff --git a/src/components/constraints/ConstraintListItem.svelte b/src/components/constraints/ConstraintListItem.svelte index 804f864da6..754b7c505a 100644 --- a/src/components/constraints/ConstraintListItem.svelte +++ b/src/components/constraints/ConstraintListItem.svelte @@ -8,39 +8,84 @@ import VisibleShowIcon from '@nasa-jpl/stellar/icons/visible_show.svg?component'; import WarningIcon from '@nasa-jpl/stellar/icons/warning.svg?component'; import { createEventDispatcher } from 'svelte'; - import { PlanStatusMessages } from '../../enums/planStatusMessages'; import { SearchParameters } from '../../enums/searchParameters'; import { Status } from '../../enums/status'; - import type { ConstraintMetadata, ConstraintPlanSpec, ConstraintResponse } from '../../types/constraint'; + import type { + ConstraintDefinition, + ConstraintMetadata, + ConstraintPlanSpecification, + ConstraintResponse, + } from '../../types/constraint'; + import type { FormParameter } from '../../types/parameter'; import { getTarget } from '../../utilities/generic'; import { permissionHandler } from '../../utilities/permissionHandler'; import { pluralize } from '../../utilities/text'; import { tooltip } from '../../utilities/tooltip'; import Collapse from '../Collapse.svelte'; import ContextMenuItem from '../context-menu/ContextMenuItem.svelte'; + import Parameters from '../parameters/Parameters.svelte'; import StatusBadge from '../ui/StatusBadge.svelte'; import ConstraintViolationButton from './ConstraintViolationButton.svelte'; export let constraint: ConstraintMetadata; - export let constraintPlanSpec: ConstraintPlanSpec; + export let constraintPlanSpec: ConstraintPlanSpecification; export let constraintResponse: ConstraintResponse; + export let deletePermissionError: string = 'You do not have permission to delete constraints for this plan.'; + export let editPermissionError: string = 'You do not have permission to edit constraints for this plan.'; export let modelId: number | undefined; - export let hasReadPermission: boolean = false; + export let hasDeletePermission: boolean = false; export let hasEditPermission: boolean = false; - export let readOnly: boolean = false; + export let hasReadPermission: boolean = false; + export let readPermissionError: string = 'You do not have permission to view this constraint.'; export let totalViolationCount: number = 0; export let visible: boolean = true; const dispatch = createEventDispatcher<{ - toggleVisibility: { id: number; visible: boolean }; - updateConstraintPlanSpec: ConstraintPlanSpec; + deleteConstraintInvocation: ConstraintPlanSpecification; + duplicateConstraintInvocation: ConstraintPlanSpecification; + toggleVisibility: { constraintId: number; invocationId: number; visible: boolean }; + updateConstraintPlanSpec: ConstraintPlanSpecification; }>(); + let formParameters: FormParameter[] = []; let revisions: number[] = []; + let version: Pick | undefined = undefined; $: revisions = constraint.versions.map(({ revision }) => revision); $: violationCount = constraintResponse?.results?.violations?.length; $: success = constraintResponse?.success; + $: { + if (constraintPlanSpec.constraint_revision !== null) { + version = constraint.versions.find(version => version.revision === constraintPlanSpec.constraint_revision); + } else { + // if the `constraint_revision` is null, that means to use the latest version of the definition + // the query for this constraint returns the versions in descending order, so the first entry in the array should correspond to the latest version + version = constraint.versions[0]; + } + const schema = version?.parameter_schema; + if (schema && schema.type === 'struct') { + formParameters = Object.entries(schema.items).map(([name, subschema], i) => ({ + errors: null, + name, + order: i, + required: true, + schema: subschema, + value: (constraintPlanSpec && constraintPlanSpec.arguments && constraintPlanSpec.arguments[name]) || '', + valueSource: 'none', + })); + } else { + formParameters = []; + } + } + + function onDuplicateConstraintInvocation() { + dispatch('duplicateConstraintInvocation', { + ...constraintPlanSpec, + }); + } + function onDeleteConstraintInvocation() { + dispatch('deleteConstraintInvocation', constraintPlanSpec); + } function onEnable(event: Event) { const { value: enabled } = getTarget(event); @@ -57,6 +102,16 @@ constraint_revision: revision === '' ? null : parseInt(`${revision}`), }); } + + function onChangeFormParameters(event: CustomEvent) { + const { + detail: { name, value }, + } = event; + dispatch('updateConstraintPlanSpec', { + ...constraintPlanSpec, + arguments: { ...constraintPlanSpec.arguments, [name]: value }, + }); + }
@@ -70,9 +125,7 @@ on:click|stopPropagation use:permissionHandler={{ hasPermission: hasEditPermission, - permissionError: readOnly - ? PlanStatusMessages.READ_ONLY - : 'You do not have permission to edit plan constraints', + permissionError: editPermissionError, }} use:tooltip={{ content: `${constraintPlanSpec.enabled ? 'Disable constraint' : 'Enable constraint'} on plan`, @@ -108,10 +161,16 @@ {/if} + {constraintPlanSpec.invocation_id}
+ + + + @@ -155,13 +216,41 @@ permissionHandler, { hasPermission: hasReadPermission, - permissionError: 'You do not have permission to edit this constraint', + permissionError: readPermissionError, }, ], ]} > View Constraint + + Duplicate Invocation + + + Delete Invocation + diff --git a/src/components/constraints/ConstraintsPanel.svelte b/src/components/constraints/ConstraintsPanel.svelte index c7ef1695e1..1c2f74d40c 100644 --- a/src/components/constraints/ConstraintsPanel.svelte +++ b/src/components/constraints/ConstraintsPanel.svelte @@ -5,16 +5,18 @@ import FilterIcon from '@nasa-jpl/stellar/icons/filter.svg?component'; import PlanLeftArrow from '@nasa-jpl/stellar/icons/plan_with_left_arrow.svg?component'; import PlanRightArrow from '@nasa-jpl/stellar/icons/plan_with_right_arrow.svg?component'; + import RefreshIcon from '@nasa-jpl/stellar/icons/refresh.svg?component'; import VisibleHideIcon from '@nasa-jpl/stellar/icons/visible_hide.svg?component'; import VisibleShowIcon from '@nasa-jpl/stellar/icons/visible_show.svg?component'; import { PlanStatusMessages } from '../../enums/planStatusMessages'; import { Status } from '../../enums/status'; import { allowedConstraintPlanSpecMap, - allowedConstraintSpecs, + allowedConstraintPlanSpecs, cachedConstraintsStatus, constraintPlanSpecs, constraintResponseMap, + constraintResponses, constraintVisibilityMap, constraintsMap, constraintsStatus, @@ -30,9 +32,8 @@ import { simulationStatus } from '../../stores/simulation'; import type { User } from '../../types/app'; import type { - ConstraintDefinition, - ConstraintMetadata, - ConstraintPlanSpec, + ConstraintInvocationMap, + ConstraintPlanSpecification, ConstraintResponse, } from '../../types/constraint'; import type { FieldStore } from '../../types/form'; @@ -56,17 +57,21 @@ export let gridSection: ViewGridSection; export let user: User | null; - let showAll: boolean = true; - let filterText: string = ''; - let filteredConstraints: ConstraintPlanSpec[] = []; + let constraintToConstraintResponseMap: ConstraintInvocationMap = {}; + let deletePermissionError: string; + let editPermissionError: string; let endTime: string; let endTimeField: FieldStore; + let filteredConstraintPlanSpecifications: ConstraintPlanSpecification[] = []; + let filteredViolationCount: number = 0; + let filterText: string = ''; + let hasSpecEditPermission: boolean; let numOfPrivateConstraints: number = 0; + let showAll: boolean = true; + let showConstraintsWithNoViolations: boolean = true; + let showFilters: boolean = false; let startTime: string; let startTimeField: FieldStore; - let showFilters: boolean = false; - let showConstraintsWithNoViolations: boolean = true; - let constraintToConstraintResponseMap: Record = {}; $: if ($plan) { startTime = formatDate(new Date($plan.start_time), $plugins.time.primary.format); @@ -76,19 +81,33 @@ } else { endTime = ''; } + hasSpecEditPermission = featurePermissions.constraintsPlanSpec.canUpdate(user, $plan) && !$planReadOnly; + + editPermissionError = $planReadOnly + ? PlanStatusMessages.READ_ONLY + : 'You do not have permission to edit constraints for this plan.'; + deletePermissionError = hasSpecEditPermission + ? editPermissionError + : 'You cannot delete the last invocation of this constraint.'; } $: startTimeField = field(startTime, [required, $plugins.time.primary.validate]); $: endTimeField = field(endTime, [required, $plugins.time.primary.validate]); $: startTimeMs = typeof startTime === 'string' ? $plugins.time.primary.parse(startTime)?.getTime() : null; $: endTimeMs = typeof endTime === 'string' ? $plugins.time.primary.parse(endTime)?.getTime() : null; - $: if ($allowedConstraintSpecs && $constraintResponseMap && startTimeMs && endTimeMs) { + $: if ($allowedConstraintPlanSpecs && $constraintResponseMap && startTimeMs && endTimeMs) { constraintToConstraintResponseMap = {}; - $allowedConstraintSpecs.forEach(constraintPlanSpec => { - const constraintResponse = $constraintResponseMap[constraintPlanSpec.constraint_id]; + $allowedConstraintPlanSpecs.forEach(constraintPlanSpec => { + const { constraint_id: constraintId, invocation_id: invocationId } = constraintPlanSpec; + const constraintResponse = $constraintResponseMap[constraintId]?.[invocationId]; if (constraintResponse) { - constraintToConstraintResponseMap[constraintPlanSpec.constraint_id] = { - constraintId: constraintResponse.constraintId, + if (!constraintToConstraintResponseMap[constraintId]) { + constraintToConstraintResponseMap[constraintId] = {}; + } + + constraintToConstraintResponseMap[constraintId][invocationId] = { + constraintId, + constraintInvocationId: invocationId, constraintName: constraintResponse.constraintName, errors: constraintResponse.errors, results: constraintResponse.results && { @@ -108,23 +127,24 @@ } }); } - $: filteredConstraints = filterConstraints( - $allowedConstraintSpecs, + $: filteredConstraintPlanSpecifications = filterConstraints( + $allowedConstraintPlanSpecs, constraintToConstraintResponseMap, filterText, showConstraintsWithNoViolations, ); - $: filteredConstraintResponses = Object.values(constraintToConstraintResponseMap).filter(r => - filteredConstraints.find(c => c.constraint_id === r.constraintId), - ); - $: numOfPrivateConstraints = ($constraintPlanSpecs || []).length - $allowedConstraintSpecs.length; + $: numOfPrivateConstraints = ($constraintPlanSpecs || []).length - $allowedConstraintPlanSpecs.length; - $: totalViolationCount = getViolationCount(Object.values($constraintResponseMap)); - $: filteredViolationCount = getViolationCount(Object.values(filteredConstraintResponses)); + $: totalViolationCount = getViolationCount($constraintResponses); + $: filteredViolationCount = getViolationCount( + filteredConstraintPlanSpecifications.map(({ constraint_id, invocation_id }) => { + return constraintToConstraintResponseMap[constraint_id]?.[invocation_id]; + }), + ); function filterConstraints( - planSpecs: ConstraintPlanSpec[], - constraintToConstraintResponseMap: Record, + planSpecs: ConstraintPlanSpecification[], + constraintToConstraintResponseMap: ConstraintInvocationMap, filterText: string, showConstraintsWithNoViolations: boolean, ) { @@ -137,7 +157,8 @@ return false; } - const constraintResponse = constraintToConstraintResponseMap[constraintPlanSpec.constraint_id]; + const constraintResponse = + constraintToConstraintResponseMap[constraintPlanSpec.constraint_id]?.[constraintPlanSpec.invocation_id]; // Always show constraints with no violations if (!constraintResponse?.results.violations?.length) { return showConstraintsWithNoViolations; @@ -149,12 +170,30 @@ function getViolationCount(constraintResponse: ConstraintResponse[]) { return constraintResponse.reduce((count, constraintResponse) => { - return constraintResponse.results.violations + return constraintResponse?.results.violations ? constraintResponse.results.violations.filter(violation => violation.windows.length > 0).length + count : count; }, 0); } + async function onDuplicateConstraintInvocation(event: CustomEvent) { + const { + detail: { constraint_metadata, invocation_id, ...constraintPlanSpec }, + } = event; + if ($plan) { + await effects.createConstraintPlanSpecification(constraintPlanSpec, user); + } + } + + async function onDeleteConstraintInvocation(event: CustomEvent) { + const { + detail: { constraint_metadata, ...constraintPlanSpec }, + } = event; + if ($plan) { + await effects.deleteConstraintInvocations($plan, [constraintPlanSpec.invocation_id], user); + } + } + function onManageConstraints() { effects.managePlanConstraints(user); } @@ -171,7 +210,7 @@ } } - async function setTimeBoundsToView() { + async function onSetTimeBoundsToView() { await startTimeField.validateAndSet(formatDate(new Date($viewTimeRange.start), $plugins.time.primary.format)); await endTimeField.validateAndSet(formatDate(new Date($viewTimeRange.end), $plugins.time.primary.format)); onUpdateStartTime(); @@ -196,7 +235,7 @@ } } - async function onUpdateConstraint(event: CustomEvent) { + async function onUpdateConstraint(event: CustomEvent) { if ($plan) { const { detail: { constraint_metadata, ...constraintPlanSpec }, @@ -206,14 +245,14 @@ } } - function resetFilters() { + function onResetFilters() { onPlanStartTimeClick(); onPlanEndTimeClick(); filterText = ''; } - function toggleVisibility(event: CustomEvent) { - setConstraintVisibility(event.detail.id, event.detail.visible); + function toggleVisibility(event: CustomEvent<{ constraintId: number; invocationId: number; visible: boolean }>) { + setConstraintVisibility(event.detail.constraintId, event.detail.invocationId, event.detail.visible); } function toggleGlobalVisibility() { @@ -227,10 +266,32 @@ $plan && effects.checkConstraints($plan, true, user)} + > + + + $plan && effects.checkConstraints($plan, user)} + on:click={() => $plan && effects.checkConstraints($plan, false, user)} use={[ [ permissionHandler, @@ -311,8 +372,8 @@ - - + + @@ -321,7 +382,7 @@
- {:else if !filteredConstraints.length} + {:else if !filteredConstraintPlanSpecifications.length}
No constraints found
@@ -336,7 +397,8 @@
{#if $cachedConstraintsStatus} - {filteredConstraints.length} of {$allowedConstraintSpecs.length} constraints, {filteredViolationCount} of + {filteredConstraintPlanSpecifications.length} of {$allowedConstraintPlanSpecs.length} constraints, {filteredViolationCount} + of {totalViolationCount} violations {:else} Constraints not checked @@ -361,18 +423,26 @@
- {#each filteredConstraints as constraint} + {#each filteredConstraintPlanSpecifications as constraint (constraint.invocation_id)} {#if $constraintsMap[constraint.constraint_id]} 1} hasReadPermission={featurePermissions.constraints.canRead(user)} - hasEditPermission={$plan ? featurePermissions.constraintsPlanSpec.canUpdate(user, $plan) : false} modelId={$plan?.model.id} - totalViolationCount={$constraintResponseMap[constraint.constraint_id]?.results.violations?.length || 0} - visible={$constraintVisibilityMap[constraint.constraint_id]} + totalViolationCount={$constraintResponseMap[constraint.constraint_id]?.[constraint.invocation_id]?.results + .violations?.length || 0} + visible={$constraintVisibilityMap[constraint.constraint_id]?.[constraint.invocation_id]} on:updateConstraintPlanSpec={onUpdateConstraint} + on:duplicateConstraintInvocation={onDuplicateConstraintInvocation} + on:deleteConstraintInvocation={onDeleteConstraintInvocation} on:toggleVisibility={toggleVisibility} /> {/if} diff --git a/src/components/modals/ManagePlanConstraintsModal.svelte b/src/components/modals/ManagePlanConstraintsModal.svelte index 88b2153579..d690ac1fa2 100644 --- a/src/components/modals/ManagePlanConstraintsModal.svelte +++ b/src/components/modals/ManagePlanConstraintsModal.svelte @@ -7,15 +7,18 @@ import { PlanStatusMessages } from '../../enums/planStatusMessages'; import { SearchParameters } from '../../enums/searchParameters'; import { - allowedConstraintPlanSpecMap, - allowedConstraintSpecs, + allowedConstraintPlanSpecs, constraints, initialConstraintPlanSpecsLoading, initialConstraintsLoading, } from '../../stores/constraints'; import { plan, planId, planReadOnly } from '../../stores/plan'; import type { User } from '../../types/app'; - import type { ConstraintMetadata, ConstraintPlanSpec, ConstraintPlanSpecInsertInput } from '../../types/constraint'; + import type { + ConstraintMetadata, + ConstraintPlanSpecification, + ConstraintPlanSpecInsertInput, + } from '../../types/constraint'; import type { DataGridColumnDef } from '../../types/data-grid'; import effects from '../../utilities/effects'; import { permissionHandler } from '../../utilities/permissionHandler'; @@ -110,8 +113,8 @@ const includesName = constraint.name.toLocaleLowerCase().includes(filterTextLowerCase); return includesId || includesName; }); - $: selectedConstraints = $allowedConstraintSpecs.reduce( - (prevBooleanMap: Record, constraintPlanSpec: ConstraintPlanSpec) => { + $: selectedConstraints = $allowedConstraintPlanSpecs.reduce( + (prevBooleanMap: Record, constraintPlanSpec: ConstraintPlanSpecification) => { return { ...prevBooleanMap, [constraintPlanSpec.constraint_id]: true, @@ -210,10 +213,14 @@ ) => { const constraintId = parseInt(selectedConstraintId); const isSelected = selectedConstraints[constraintId]; - const constraintPlanSpec = $allowedConstraintPlanSpecMap[constraintId]; + // if we find at least one constraint invocation with the selected constraint_id, we don't want to insert this constraint_id into the plan spec + // i.e. this constraint was already selected when we entered the modal, so we don't want to kick off an update, which would cause a duplicate invocation to appear + const constraintsInPlanSpecification = $allowedConstraintPlanSpecs.filter( + constraintPlanSpecification => constraintPlanSpecification.constraint_id === constraintId, + ); if (isSelected) { - if (!constraintPlanSpec || constraintPlanSpec.constraint_metadata?.owner === user?.id) { + if (!constraintsInPlanSpecification.length) { return { ...prevConstraintPlanSpecUpdates, constraintPlanSpecsToAdd: [ @@ -233,7 +240,7 @@ ...prevConstraintPlanSpecUpdates, constraintPlanSpecIdsToDelete: [ ...prevConstraintPlanSpecUpdates.constraintPlanSpecIdsToDelete, - constraintId, + ...constraintsInPlanSpecification.map(({ invocation_id }) => invocation_id), ], }; } diff --git a/src/components/scheduling/SchedulingConditionsPanel.svelte b/src/components/scheduling/SchedulingConditionsPanel.svelte index 226e88328e..7b91665f0a 100644 --- a/src/components/scheduling/SchedulingConditionsPanel.svelte +++ b/src/components/scheduling/SchedulingConditionsPanel.svelte @@ -27,10 +27,14 @@ let activeElement: HTMLElement; let conditionsFilterText: string = ''; + let hasSpecEditPermission: boolean; let filteredSchedulingConditionSpecs: SchedulingConditionPlanSpecification[] = []; let numOfPrivateConditions: number = 0; let visibleSchedulingConditionSpecs: SchedulingConditionPlanSpecification[] = []; + $: if ($plan) { + hasSpecEditPermission = featurePermissions.schedulingConditionsPlanSpec.canUpdate(user, $plan) && !$planReadOnly; + } // TODO: remove this after db merge as it becomes redundant $: visibleSchedulingConditionSpecs = ($allowedSchedulingConditionSpecs || []).filter( ({ condition_metadata: conditionMetadata }) => { @@ -139,9 +143,10 @@ ) { + async function onDuplicateGoalInvocation(event: CustomEvent) { const { detail: { goal_metadata, goal_invocation_id, priority, ...goalPlanSpec }, } = event; @@ -112,13 +106,13 @@ } } - async function deleteGoalInvocation(event: CustomEvent) { + async function onDeleteGoalInvocation(event: CustomEvent) { const { detail: { goal_metadata, specification_id, ...goalPlanSpec }, } = event; if ($plan) { - await effects.deleteSchedulingGoalInvocation($plan, specification_id, [goalPlanSpec.goal_invocation_id], user); + await effects.deleteSchedulingGoalInvocations($plan, specification_id, [goalPlanSpec.goal_invocation_id], user); } } @@ -231,16 +225,17 @@ {#each filteredSchedulingGoalSpecs as specGoal (specGoal.goal_invocation_id)} {#if $schedulingGoalsMap[specGoal.goal_id]} {/if} {/each} diff --git a/src/components/scheduling/conditions/SchedulingCondition.svelte b/src/components/scheduling/conditions/SchedulingCondition.svelte index 6f161d964d..d99f988515 100644 --- a/src/components/scheduling/conditions/SchedulingCondition.svelte +++ b/src/components/scheduling/conditions/SchedulingCondition.svelte @@ -3,7 +3,6 @@